Онлайн-школа SkillFactory

Проект: Анализ резюме из HeadHunter

drawing

Исследование структуры данных¶

In [1]:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.express as px
import plotly.graph_objects as go
import os

Загрузка данных¶

In [2]:
# Загрузка данных в зависимости от среды выполнения (vscode, jupiter, collab.net)

file_id = '1-0S3t50XV_X7b4AIY_h3cRiBZKdZpObz'
google_drive_url='https://drive.google.com/uc?export=download&confirm=no_antivirus&id='

local_file  = './data/dst-3.0_16_1_hh_database.zip'
remote_url  = google_drive_url + file_id

# загружаем локальный датасет, если есть. если нет - скачиваем с google.drive
if os.path.exists('./data') and os.path.exists(local_file):
    print('Load local:', local_file)
    url = local_file
else:
    print('Load remote:', remote_url)
    url = remote_url

# Ускорим перезагрузку если датасет уже загружен
hh_df : pd.DataFrame
if 'hh_df' in globals():
    hh_data = hh_df.copy()
    print('Reload copy from memory')
else:
    hh_df = pd.read_csv(url, compression='zip', sep=';')
    hh_data = hh_df.copy()
    print('Loaded')
Load local: ./data/dst-3.0_16_1_hh_database.zip
Loaded

Базовый анализ¶

Ознакомление со структурой набора данных¶

In [3]:
display(hh_data)
Пол, возраст ЗП Ищет работу на должность: Город, переезд, командировки Занятость График Опыт работы Последнее/нынешнее место работы Последняя/нынешняя должность Образование и ВУЗ Обновление резюме Авто
0 Мужчина , 39 лет , родился 27 ноября 1979 29000 руб. Системный администратор Советск (Калининградская область) , не готов к... частичная занятость, проектная работа, полная ... гибкий график, полный день, сменный график, ва... Опыт работы 16 лет 10 месяцев Август 2010 — п... МАОУ "СОШ № 1 г.Немана" Системный администратор Неоконченное высшее образование 2000 Балтийск... 16.04.2019 15:59 Имеется собственный автомобиль
1 Мужчина , 60 лет , родился 20 марта 1959 40000 руб. Технический писатель Королев , не готов к переезду , готов к редким... частичная занятость, проектная работа, полная ... гибкий график, полный день, сменный график, уд... Опыт работы 19 лет 5 месяцев Январь 2000 — по... Временный трудовой коллектив Менеджер проекта, Аналитик, Технический писатель Высшее образование 1981 Военно-космическая ак... 12.04.2019 08:42 Не указано
2 Женщина , 36 лет , родилась 12 августа 1982 20000 руб. Оператор Тверь , не готова к переезду , не готова к ком... полная занятость полный день Опыт работы 10 лет 3 месяца Октябрь 2004 — Де... ПАО Сбербанк Кассир-операционист Среднее специальное образование 2002 Профессио... 16.04.2019 08:35 Не указано
3 Мужчина , 38 лет , родился 25 июня 1980 100000 руб. Веб-разработчик (HTML / CSS / JS / PHP / базы ... Саратов , не готов к переезду , готов к редким... частичная занятость, проектная работа, полная ... гибкий график, удаленная работа Опыт работы 18 лет 9 месяцев Август 2017 — Ап... OpenSoft Инженер-программист Высшее образование 2002 Саратовский государст... 08.04.2019 14:23 Не указано
4 Женщина , 26 лет , родилась 3 марта 1993 140000 руб. Региональный менеджер по продажам Москва , не готова к переезду , готова к коман... полная занятость полный день Опыт работы 5 лет 7 месяцев Региональный мене... Мармелад Менеджер по продажам Высшее образование 2015 Кгу Психологии и педаг... 22.04.2019 10:32 Не указано
... ... ... ... ... ... ... ... ... ... ... ... ...
44739 Мужчина , 30 лет , родился 17 января 1989 50000 руб. Финансист, аналитик, экономист, бухгалтер, мен... Тверь , готов к переезду (Москва, Химки) , гот... полная занятость полный день, удаленная работа Опыт работы 7 лет 7 месяцев Финансист, аналит... ООО "IAS" (независимый участник объединения Ru... Руководитель субгруппы Высшее образование 2015 Московский гуманитарн... 22.04.2019 12:32 Не указано
44740 Мужчина , 27 лет , родился 5 марта 1992 39000 руб. Системный администратор, IT-специалист Липецк , готов к переезду , готов к командировкам проектная работа, частичная занятость, полная ... удаленная работа, гибкий график, полный день, ... Опыт работы 7 лет Системный администратор, IT... ИП Пестрецов Предприниматель Высшее образование (Бакалавр) 2016 Воронежски... 22.04.2019 13:11 Не указано
44741 Женщина , 48 лет , родилась 26 декабря 1970 40000 руб. Аналитик данных, Математик Челябинск , готова к переезду , готова к редки... полная занятость полный день, удаленная работа Опыт работы 21 год 5 месяцев Январь 1998 — по... ОАО «ЧМК», Исследовательско-Технологический Це... Начальник группы аналитики Высшее образование 2000 Южно-Уральский госуда... 09.04.2019 05:07 Не указано
44742 Мужчина , 24 года , родился 6 октября 1994 20000 руб. Контент-менеджер Тамбов , не готов к переезду , не готов к кома... частичная занятость, полная занятость удаленная работа Опыт работы 3 года 10 месяцев Контент-менедже... IQ-Maxima Менеджер проектов Высшее образование 2015 Тамбовский государств... 26.04.2019 14:25 Имеется собственный автомобиль
44743 Мужчина , 38 лет , родился 25 апреля 1980 120000 руб. Руководитель проекта Москва , не готов к переезду , не готов к кома... полная занятость полный день Опыт работы 15 лет 10 месяцев Руководитель пр... ПАО ГК ТНС энерго Руководитель отдела технической поддержки Высшее образование 1997 Южно-Российский госуд... 05.07.2018 20:15 Не указано

44744 rows × 12 columns

Общая информация. Размер. Пропуски¶

In [4]:
print('NaN`s   Column')
for colm in hh_data.columns:
    cnt_na = hh_data[colm].isna().sum()
    if cnt_na:  print(f'{cnt_na:5}  ', colm)
        
hh_data.info()
NaN`s   Column
  168   Опыт работы
    1   Последнее/нынешнее место работы
    2   Последняя/нынешняя должность
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 44744 entries, 0 to 44743
Data columns (total 12 columns):
 #   Column                           Non-Null Count  Dtype 
---  ------                           --------------  ----- 
 0   Пол, возраст                     44744 non-null  object
 1   ЗП                               44744 non-null  object
 2   Ищет работу на должность:        44744 non-null  object
 3   Город, переезд, командировки     44744 non-null  object
 4   Занятость                        44744 non-null  object
 5   График                           44744 non-null  object
 6   Опыт работы                      44576 non-null  object
 7   Последнее/нынешнее место работы  44743 non-null  object
 8   Последняя/нынешняя должность     44742 non-null  object
 9   Образование и ВУЗ                44744 non-null  object
 10  Обновление резюме                44744 non-null  object
 11  Авто                             44744 non-null  object
dtypes: object(12)
memory usage: 4.1+ MB

Статистика по столбцам¶

In [5]:
hh_data.describe(include='object')
Out[5]:
Пол, возраст ЗП Ищет работу на должность: Город, переезд, командировки Занятость График Опыт работы Последнее/нынешнее место работы Последняя/нынешняя должность Образование и ВУЗ Обновление резюме Авто
count 44744 44744 44744 44744 44744 44744 44576 44743 44742 44744 44744 44744
unique 16003 690 14929 10063 38 47 44413 30214 16927 40148 18838 2
top Мужчина , 32 года , родился 17 сентября 1986 50000 руб. Системный администратор Москва , не готов к переезду , не готов к кома... полная занятость полный день Опыт работы 10 лет 8 месяцев Апрель 2018 — по... Индивидуальное предпринимательство / частная п... Системный администратор Высшее образование 1987 Военный инженерный Кра... 07.05.2019 09:50 Не указано
freq 18 4064 3099 1261 30026 22727 3 935 2062 4 25 32268

Преобразование данных. Создание признаков¶

Образование¶

In [6]:
# Сохраним преобразование и удалим исходный признак
hh_data['Образование'] = hh_data['Образование и ВУЗ'].apply(lambda s : s[:s.find(' образование')].lower())
hh_data.drop(columns='Образование и ВУЗ', inplace=True)

# Оценим
display( hh_data['Образование'].value_counts() )
высшее                 33863
среднее специальное     5765
неоконченное высшее     4557
среднее                  559
Name: Образование, dtype: int64

Возраст, пол¶

Извлечение информации. Создание раздельных признаков

In [7]:
# Выберем нужные признаки и вернем кортеджем (Пол,Возраст)
def extract_person_info(raw:str) -> tuple:
    info = [s.strip() for s in raw.split(',')]
    return info[0][:1].upper(), int(info[1].split()[0])

# Сделаем парсинг за раз. Загрузим в промежуточный датафрэйм
person_info = pd.DataFrame( hh_data['Пол, возраст'].apply(extract_person_info).to_list(),
                            columns=['sex','age'] )

# Сохраним в исходный датафрейм
hh_data['Пол'] = person_info.sex
hh_data['Возраст'] = person_info.age

# Очистим память и удалим исходниый признак
del person_info
hh_data.drop(columns='Пол, возраст', inplace=True)

# проверим типы признаков
hh_data[['Пол', 'Возраст']].info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 44744 entries, 0 to 44743
Data columns (total 2 columns):
 #   Column   Non-Null Count  Dtype 
---  ------   --------------  ----- 
 0   Пол      44744 non-null  object
 1   Возраст  44744 non-null  int64 
dtypes: int64(1), object(1)
memory usage: 699.2+ KB
In [8]:
print('Распределение резюме по полу (%):')
print( (hh_data['Пол'].value_counts(normalize=True) * 100).round(2))
print('Cредний возраст соискателей:', round(hh_data['Возраст'].mean(), 1))
Распределение резюме по полу (%):
М    80.93
Ж    19.07
Name: Пол, dtype: float64
Cредний возраст соискателей: 32.2

Опыт работы¶

In [9]:
# Проверим на пропуски
print('Кол-во NaN:' ,hh_data['Опыт работы'].isna().sum())

# Парсинг строки с опытом работы
def extract_experience(raw:str) -> int:
    """Преобразует входящую строку с опытом работы 
    в годах и месяцах в целое - количество месяцев
    """
    template_begin = 'Опыт работы'
    template_begin_len = len(template_begin)
    # Если NaN или "неформат" - отдаем NaN
    if raw is np.nan:
        return raw
    elif not raw.startswith(template_begin):
        return np.nan 
    else:
        # Удаляем остаток строки с доп.инфо
        space2x_pos = raw.find('  ')
        template_end_pos = space2x_pos if space2x_pos != -1 else None
        raw_extract = raw[template_begin_len + 1 : template_end_pos].split()
        # проверяем на варианты, отдаем в месяцах.
        if raw_extract[1][:5] == 'месяц':
            return int(raw_extract[0])
        elif raw_extract[1][:3] in ('год', 'лет'):
            if len(raw_extract) > 3 and raw_extract[3][:5] == 'месяц':
                return int(raw_extract[0])*12 + int(raw_extract[2])
            else:
                return int(raw_extract[0])*12
        else:
            return np.nan

# Преобразуем и сохраним в исходный датасет
hh_data['Опыт работы (месяц)'] = hh_data['Опыт работы'].apply(extract_experience)

# оценим визуально 
# display( hh_data[['Опыт работы', 'Опыт работы (месяц)',]].sample(10) )
# и удалим исходный признак
hh_data.drop(columns='Опыт работы', inplace=True)

# к заданию
print('Медиана опыта работы (месяцев):', round(hh_data['Опыт работы (месяц)'].median()))
# и оценим на прирост кол-ва пропусков
print('Кол-во NaN:' ,hh_data['Опыт работы (месяц)'].isna().sum())
Кол-во NaN: 168
Медиана опыта работы (месяцев): 100
Кол-во NaN: 170

Город, переезд, командировки.¶

Извлечение информации, создание признаков-индикаторов

In [10]:
# Удалим лишнюю информацию в скобках
def remove_bracket(raw:str) -> tuple:
    """Удаляет скобки с содержимым"""
    bracket_counter = 0
    result = ''
    for ch in raw:
        if ch == '(':
            bracket_counter += 1
        if ch == ')':
            bracket_counter -= 1
        elif not bracket_counter:
            result += ch
    return [x.strip() for x in result.split(',', maxsplit=1)]

# Загрузим раздельно город и информацию в промежуточный датафрэйм за один проход
reloc_df = pd.DataFrame(
        data=hh_data['Город, переезд, командировки'].apply(remove_bracket).to_list(), 
        columns=('city','reinfo')
)
# Удалим исходный признак
hh_data.drop(columns='Город, переезд, командировки', inplace=True)

# Удалим информацию о станции метро, если есть. остаток вернем списком (переезд, коман-ки)
# +1 NaN в списке - защита от отсутсвия запятой последнего блока
def extract_reloc(raw:str) -> tuple:
    """Разделяет информацию о готовности 
    с удалением информации о метро"""
    if raw.startswith('м. '):
        result = [x.strip() for x in raw.split(',')[1:]] + [np.nan]
    else:
        result = [x.strip() for x in raw.split(',')] + [np.nan]
    return result[:2]

# Загрузим в промежуточный датафрэйм за один проход
reloc_info_df = pd.DataFrame(
        data=reloc_df.reinfo.apply(extract_reloc).to_list(),
        columns=('relocate','worktrip')
    )
# Удаляем исходный признак
reloc_df.drop(columns='reinfo', inplace=True)

# сохраняем готовность к переезду в признак-индикатор по (не]
reloc_info_df['relocate_indicator'] = reloc_info_df['relocate'].apply( lambda x : x[:2] != 'не' )

# сохраняем готовность к командировками в признак-индикатор по (г] учитывая наличие NaN и ""
# если "г" - все же "мусор", отбераем по (готов]
reloc_info_df['worktrip_indicator'] = reloc_info_df['worktrip'].apply( 
    lambda x : True if x is not np.nan and len(x) and x[0] == 'г' else False
)

# Формируем признак "Город"
million_cities = ['Новосибирск', 'Екатеринбург', 'Нижний Новгород', 'Казань', 'Челябинск', 'Омск', 'Самара', 'Ростов-на-Дону', 'Уфа', 'Красноярск', 'Пермь', 'Воронеж', 'Волгоград' ]
capitals = ['Москва', 'Санкт-Петербург']

def location_category(loc_name):
    """Преобразует наименование населеного пункта 
    в тип-категорию"""
    if loc_name in capitals:
        return loc_name
    elif loc_name in million_cities:
        return 'город-миллионник'
    else:
        return 'другие'

# Сохраним
reloc_df['location'] = reloc_df.city.apply(location_category)

# присоеденим инфо о месте локации, переезде и командировке
reloc_df = reloc_df.join(reloc_info_df)
#удалим промежуточный датасет
del reloc_info_df

# Сохраним в исходный
hh_data['Город'] = reloc_df.location
hh_data['Готовность к переезду'] = reloc_df.relocate_indicator
hh_data['Готовность к командировкам'] =reloc_df.worktrip_indicator
# и удалим промежуточный датасет
del reloc_df

# Ответы к заданиям
display(hh_data['Город'].value_counts(normalize=True).mul(100).round(2))
display(hh_data[['Готовность к переезду', 'Готовность к командировкам']] \
      .value_counts(normalize=True).mul(100).round() )
# Или
mask = hh_data['Готовность к переезду'] & hh_data['Готовность к командировкам']
print('Готовы одновременно к переездам и командировкам (%):', 
      round(hh_data[mask].shape[0] / hh_data.shape[0] * 100))
Москва              37.15
другие              35.43
город-миллионник    16.39
Санкт-Петербург     11.03
Name: Город, dtype: float64
Готовность к переезду  Готовность к командировкам
False                  True                          39.0
True                   True                          32.0
False                  False                         25.0
True                   False                          4.0
dtype: float64
Готовы одновременно к переездам и командировкам (%): 32

Признаки «Занятость» и «График». Преобразование One Hot Encode¶

Создание признаков-индикаторов из списка значений переменной длины в исходном признаке.

In [11]:
#(!) Прототип механизма кодирование описан в первой части черновика (explore-a.ipynb)
#    и здесь не описывается для краткости по требованиям задания
# Сделаем выборку наборов значений в множества. избавимся от внешние пробелов
ss_predict = hh_data['Занятость'].apply(lambda raw: {x.strip() for x in raw.split(',')})
#display(ss_predict)

# Отберем полный набор значений для индексов
idx_set = set()
for i in ss_predict:
    idx_set.update(i)
#print(idx_set)

# исходный Series с "выключенными" индикаторами
ssbase = pd.Series(False, index=list(idx_set))

# обработчик - генератор Series row c "включенными" индикаторами
def extract_cross_typeset(raw:set) -> pd.Series:
    row = ssbase.copy()
    row.update(pd.Series(True, index=list(raw)))
    return row

# (!) Плата за универсальность - относительно долгое время обработки. 30-40 сек.
ohr_data = ss_predict.apply(extract_cross_typeset)
# Закрепим полученный результат
hh_data = hh_data.join(ohr_data).drop(columns='Занятость')
display(ohr_data.info())
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 44744 entries, 0 to 44743
Data columns (total 5 columns):
 #   Column               Non-Null Count  Dtype
---  ------               --------------  -----
 0   полная занятость     44744 non-null  bool 
 1   стажировка           44744 non-null  bool 
 2   частичная занятость  44744 non-null  bool 
 3   волонтерство         44744 non-null  bool 
 4   проектная работа     44744 non-null  bool 
dtypes: bool(5)
memory usage: 218.6 KB
None
In [12]:
# Повторим для "График"
ss_predict = hh_data['График'].apply(lambda raw: {x.strip() for x in raw.split(',')})
#display(ss_predict)

idx_set = set()
for i in ss_predict:
    idx_set.update(i)
#print(idx_set)
#
ssbase = pd.Series(False, index=list(idx_set))
# Функция-обработчик та же, ssbase - ссылка
ohr_data = ss_predict.apply(extract_cross_typeset)

# Закрепим полученный результат и удалим исходные и промежуточные данные
hh_data = hh_data.join(ohr_data).drop(columns='График')
display(ohr_data.info())
del ohr_data, ss_predict

# Все признаки-индикаторы 
#print('All row count:',hh_data.shape[0])
display(
hh_data[[c for c in hh_data.columns if hh_data[c].dtype.name=='bool']] \
    .sum().sort_values(ascending=False) \
    .to_frame(name='Кол-во').style.bar(align='mid')
)
# К заданиям
mask = hh_data['проектная работа'] & hh_data['волонтерство']
print('Готовы на проектную работу и волонтёрство:', mask.sum())
mask = hh_data['вахтовый метод'] & hh_data['гибкий график']
print('Ищут вахтовый метод и гибкий график:', mask.sum())
del mask
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 44744 entries, 0 to 44743
Data columns (total 5 columns):
 #   Column            Non-Null Count  Dtype
---  ------            --------------  -----
 0   гибкий график     44744 non-null  bool 
 1   полный день       44744 non-null  bool 
 2   сменный график    44744 non-null  bool 
 3   вахтовый метод    44744 non-null  bool 
 4   удаленная работа  44744 non-null  bool 
dtypes: bool(5)
memory usage: 218.6 KB
None
  Кол-во
полная занятость 43284
полный день 41716
Готовность к командировкам 31646
Готовность к переезду 16025
гибкий график 15584
удаленная работа 15022
частичная занятость 13136
сменный график 12725
проектная работа 8068
вахтовый метод 3084
стажировка 2804
волонтерство 486
Готовы на проектную работу и волонтёрство: 436
Ищут вахтовый метод и гибкий график: 2311

Заработная плата. Валюта. Конвертор курсов.¶

Преобразование желаемой заработной платы из национальных в единую валюту (в рубли).

In [13]:
# Загрузка курсов валют на даты обновления резюме
local_file  = './data/ExchangeRates.zip'

file_id = '13r8O_ynFNdnWgU7ELZ2jqVeKLTWhV9Ci'
remote_url  = google_drive_url + file_id

# есть локальный - берем локальный, нет - тянем с гугл диска
if  os.path.exists('./data') and os.path.exists(local_file):
    print('Load local:', local_file)
    url = local_file
else:
    print('Load remote:', remote_url)
    url = remote_url

# Ускорим перезагрузку если уже "стянули"
exchange_df : pd.DataFrame
if 'exchange_df' in globals():
    exchange_data = exchange_df.copy()
    print('Reload copy from memory')
else:
    exchange_df = pd.read_csv(url, compression='zip', sep=',')
    exchange_data = exchange_df.copy()
    print('Loaded')  
Load local: ./data/ExchangeRates.zip
Loaded
In [14]:
#  Таблица валют и пропорций конвертации из задания 
# для получения ISO кода валюты из сокращенного наименования.
# От сюда же возмем пропорцию (CurrencyFactor) для конвертора.

convertor_data = pd.DataFrame(
    data=[['грн',     'UAH',     10, 'гривна'],
          ['USD',     'USD',      1, 'доллар'],
          ['EUR',     'EUR',      1, 'евро'],
          ['белруб',  'BYN',      1, 'белорусский рубль'],
          ['KGS',     'KGS',     10, 'киргизский сом'],
          ['сум',     'UZS', 10_000, 'узбекский сум'],
          ['AZN',     'AZN',      1, 'азербайджанский манат'],
          ['KZT',     'KZT',    100, 'казахстанский тенге'],
          ['руб',     'RUB',      1, 'российский рубль']],
    columns=['ID', 'ISO', 'CF', 'NAME'],
).set_index('ID')

# Разбираем ЗП на сумму и валюту. Готовим дату для конвертора
salary_df = hh_data[['ЗП', 'Обновление резюме']].copy()
salary_df['Валюта'] = salary_df['ЗП'].apply(lambda x: x.split()[1].replace('.',''))
salary_df['ЗП'] = salary_df['ЗП'].apply(lambda x: x.split()[0]).astype('float')
salary_df['Обновление резюме'] = pd.to_datetime(salary_df['Обновление резюме'], format='%d.%m.%Y %H:%M').dt.date

# Оценим
# display( salary_df.sample(3) )
display( salary_df['Валюта'].value_counts() )

# дату в ISO
exchange_data['date'] = pd.to_datetime(exchange_data.date).dt.date  #.normalize()
# И сразу установим индекс для JOIN
exchange_data.set_index(keys=['currency', 'date'], inplace=True)

# Погружаем тип валюты в ISO и пропорции для конвертации
currency_df = salary_df \
        .join(convertor_data[['ISO', 'CF']], on='Валюта') \
        .rename(columns={'ISO':'currency','Обновление резюме':'date'}) \
        .join(exchange_data[['close']], on=['currency', 'date'] )
# добовляем рубль
currency_df.loc[currency_df.currency=='RUB','close'] = 1.0
currency_df['close'] = currency_df['close'].astype('float64')

# Конвертируем в рубли
currency_df['salary'] = currency_df.apply(lambda row: row['ЗП'] * row['close'] / row['CF'], axis=1)

# Оценим конвертацию в целом (по факту выпадают почти одни RUB)
display(currency_df.sample(5))
# и в национальных валютах
no_rub_mask = currency_df['Валюта'] != 'руб'
display(currency_df[no_rub_mask].sample(5))

# Сохраняем в исходный датасет
hh_data['ЗП (руб)'] = currency_df.salary

# и удаляем исходные столбцы и промежуточные данные
hh_data.drop(columns='ЗП', inplace=True)
del exchange_df, salary_df, currency_df, convertor_data, no_rub_mask

# к заданию
print('Желаемая медианная заработная\nплата соискателей: ',
      round(hh_data['ЗП (руб)'].median()/1000), 'тыс.руб.')
руб       42471
KZT        1108
USD         628
белруб      329
EUR         106
грн          73
сум          20
KGS           6
AZN           3
Name: Валюта, dtype: int64
ЗП date Валюта currency CF close salary
10181 100000.0 2019-04-05 руб RUB 1 1.0 100000.0
36038 60000.0 2019-04-26 руб RUB 1 1.0 60000.0
24151 60000.0 2019-04-15 руб RUB 1 1.0 60000.0
20003 60000.0 2019-04-20 руб RUB 1 1.0 60000.0
16635 45000.0 2019-05-07 руб RUB 1 1.0 45000.0
ЗП date Валюта currency CF close salary
4116 900.0 2019-05-07 USD USD 1 63.4013 57061.17
16041 1200.0 2019-04-16 EUR EUR 1 72.7018 87242.16
36782 300.0 2019-04-22 белруб BYN 1 30.5314 9159.42
42287 300000.0 2019-04-25 KZT KZT 100 16.8799 50639.70
35513 750.0 2019-04-22 белруб BYN 1 30.5314 22898.55
Желаемая медианная заработная
плата соискателей:  59 тыс.руб.

Общий результат после преобразования признаков¶

In [15]:
hh_data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 44744 entries, 0 to 44743
Data columns (total 23 columns):
 #   Column                           Non-Null Count  Dtype  
---  ------                           --------------  -----  
 0   Ищет работу на должность:        44744 non-null  object 
 1   Последнее/нынешнее место работы  44743 non-null  object 
 2   Последняя/нынешняя должность     44742 non-null  object 
 3   Обновление резюме                44744 non-null  object 
 4   Авто                             44744 non-null  object 
 5   Образование                      44744 non-null  object 
 6   Пол                              44744 non-null  object 
 7   Возраст                          44744 non-null  int64  
 8   Опыт работы (месяц)              44574 non-null  float64
 9   Город                            44744 non-null  object 
 10  Готовность к переезду            44744 non-null  bool   
 11  Готовность к командировкам       44744 non-null  bool   
 12  полная занятость                 44744 non-null  bool   
 13  стажировка                       44744 non-null  bool   
 14  частичная занятость              44744 non-null  bool   
 15  волонтерство                     44744 non-null  bool   
 16  проектная работа                 44744 non-null  bool   
 17  гибкий график                    44744 non-null  bool   
 18  полный день                      44744 non-null  bool   
 19  сменный график                   44744 non-null  bool   
 20  вахтовый метод                   44744 non-null  bool   
 21  удаленная работа                 44744 non-null  bool   
 22  ЗП (руб)                         44744 non-null  float64
dtypes: bool(12), float64(2), int64(1), object(8)
memory usage: 4.3+ MB

Исследование зависимостей в данных¶

In [16]:
# Переведем в категориальный тип признаки, с которыми будем работать
hh_data['Образование'] = hh_data['Образование'].astype('category')
hh_data['Город'] = hh_data['Город'].astype('category')
hh_data['Ищет работу на должность:'] =  hh_data['Ищет работу на должность:'].astype('category')
hh_data['Обновление резюме'] = pd.to_datetime(hh_data['Обновление резюме']).dt.date

Распределение признака «Возраст»¶

In [17]:
fig = px.histogram( data_frame=hh_data, 
    height=540, # width=700, 
    title='Распределение возраста соискателей',
    x='Возраст',
    #nbins=100,
    marginal='box',
    #histnorm='percent',
).update_layout(bargap=0.05)
fig.show()

Вывод

  • Мода распределения - 30 лет
  • Предельные значения возраста соискателей. минимум - 14 лет, максимум - 100.
  • Основной интервал возраста большинства соискателей от 14 до 49 лет
  • Значение в 100 лет явная аномалия.
  • Значения свыше 49 лет можно отнести к потенциальным выбросам.

Распределение признака "Опыт работы (месяц)"¶

In [18]:
fig = px.histogram( data_frame=hh_data, height=540, # width=700, 
    title='Распределение опыта работы соскателя (месяцы)',
    x='Опыт работы (месяц)',
    #nbins=100,
    marginal='box',
    #histnorm='percent',
)#.update_layout(bargap=0.05)
fig.update_layout(bargap=0.05)
fig.show()

Вывод

  • Мода распределения (по графику) - 80-84 месяца, расчетная - 81 месяц
  • Предельные значения опыта работы соискателей. минимум - 1 месяц, максимум - 1188.
  • Основной интервал опыта работы большинства соискателей от 1 до 299 месяцев
  • Значение в 1188 месяцев является явной аномалией.

Распределение признака «ЗП (руб)»¶

In [19]:
fig = px.histogram( data_frame=hh_data , # hh_data[mask]
    height=540, # width=700, 
    title='Распределение желаемой заработной платы соискателя (руб)',
    x='ЗП (руб)',
    marginal='box',
    #nbins=100,
    #histnorm='percent',
).update_layout(bargap=0.05)
fig.show()
In [20]:
#Удалим выбросы, улучшим наглядность визуализации для анализа
mask = hh_data['ЗП (руб)'] <= 1E6 
fig = px.histogram( data_frame=hh_data[mask] , # hh_data[mask]
    height=540, # width=700, 
    title='Распределение желаемой заработной платы соискателя (руб) (< 1 мил.руб.)',
    x='ЗП (руб)',
    marginal='box',
    #nbins=100,
    #histnorm='percent',
).update_layout(bargap=0.05)
fig.show()

del mask

Вывод

  • Мода распределения (по графику) - 50-52 тыс.руб.
  • Предельные значения желаемой заработной платы соискателей: минимум - 1 тыс.руб, максимум - 24 мил.руб.
  • Основной интервал желаемой заработной платы большинства соискателей от 1 до 180 тыс.руб
  • Значение свыще 1 мил.руб можно отнести к явным аномалиям

Зависимость медианной «ЗП (руб)» от уровня «Образование»¶

In [21]:
mask = hh_data['ЗП (руб)'] < 1e6

fig = px.bar(
    data_frame=hh_data[mask].groupby('Образование')[['ЗП (руб)']].median().reset_index(),
    title='Зависимость медианной желаемой заработной платы от уровня образования',
    x='Образование',
    y='ЗП (руб)',
    color='Образование'
)
fig.show()

del mask

Вывод

  • Наибольшее медианное значение желаемой заработной платы находится в категории "высшее", следом идет "неоконченное высшее". Наименьший показатель разделяют между собой категории "среднее специальное" и "среднее"
  • Признак "Образование" является достаточно весомым при прогнозировании заработной платы

Распределение «ЗП (руб)» в зависимости от «Город»¶

In [22]:
mask = hh_data['ЗП (руб)'] < 1e6
fig = px.box(
    data_frame=hh_data[mask],
    title='Зависимость медианной желаемой заработной платы от места проживания',
    x='ЗП (руб)',
    y='Город',
    color='Город'
)
fig.show()

del mask

Вывод

  • По всем городам с увеличением медианы желаемой заработной платы растет и ее размах в сторону максимума.
  • Признак "Город" соискателя является крайне важным при прогнозировании заработной платы

Зависимость медианной «ЗП (руб)» от признаков «Готовность»¶

In [23]:
# сгруппируем и локализуем значения признаков
group_by = hh_data.groupby(['Готовность к переезду','Готовность к командировкам'])['ЗП (руб)'].median().reset_index()
group_by['Готовность к переезду'].replace({True:'Да', False:'Нет'}, inplace=True)
group_by['Готовность к командировкам'].replace({True:'Да', False:'Нет'}, inplace=True)
 
fig = px.bar( 
    data_frame=group_by,
    title='Медианный уровень желаемой зароботной платы<br>от готовности к переезду и командировкам',
    x='Готовность к командировкам',
    y='ЗП (руб)',
    color='Готовность к переезду',
    barmode='group',
)
#fig.update_xaxes(type='category', categoryorder='category ascending')
fig.show()

del group_by

Вывод

  • Медианный уровень желаемой зароботной платы соискателя растет в зависимости от его готовности к переезду и командировкам.
  • Можно сказать, что определяющим фактором является готовность к командировкам.

Зависимость медианной «ЗП (руб)» от «Возраст» и «Образование»¶

In [24]:
fig = px.imshow( 
    hh_data.pivot_table(
        columns='Возраст',   index='Образование',
        values='ЗП (руб)',   aggfunc=np.median ),
    title="Тепловая карта зависимости медианной желаемой заработной платы от возраста и образования",
    labels={'color':'ЗП (руб)'},
    color_continuous_scale='YlOrRd', # 'Viridis' 'Blues' 'RdBu'
)
fig.show()

Вывод

  • Наибольшая тенденция к увеличению максимальной медианной желаемой заработной платы от возраста наблюдается в группе "высшее" образование с пиком показателя возраст в 41 год. Следом по показателю роста заработной платы от возраста идет группа "неоконченное высшее" с пиком в 43 года
  • В группе "среднее" какой либо явной тенденции увеличения медианной желаемой заработной платы от возраста не наблюдается.
  • В группе "среднее специальное" есть тенденция к увеличению показателя желаемой заработной платы от возраста, но уровень желаемой зарплаты ниже чем в группах "высшее" и "неоконченное высшее"

Зависимость «Опыт работы (месяц)» от «Возраст»¶

In [25]:
hh_data['Опыт работы'] = (hh_data['Опыт работы (месяц)'] /12).round(1)

fig = px.scatter( data_frame=hh_data.assign(_vsize=1),
    height=740, # width=820,
    title='Зависимость опыта работы от возраста',
    x='Возраст',  y='Опыт работы',  color='Образование',
    opacity=0.5,  size='_vsize', size_max=8,
    hover_data={'_vsize':False},
    range_x=[-5, 105],  range_y=[-5, 105],
)
fig.add_trace( go.Scatter(x=[-5,105], y=[-5,105], mode='lines', name='Опыт работы равен возрасту',
               line = {'color':'red','width':1.5})
)
fig.add_trace( go.Scatter(x=[11,121], y=[-5,105], mode='lines', name='Опыт работы (Возраст-16)',
               line = {'color':'orange','width':1.5})
)
fig.show()

#(!) Обязательно удаляем доп. признаки для анализа, иначе кол-во дубликатов и т.п. 
# будут не соответствовать тестовым значениям. Или используем df.assign(...) для графиков
hh_data.drop(columns='Опыт работы', inplace=True)

Вывод

  • Присутствует видимая зависимость роста опыта работы от возраста.
  • Наблюдается явный "мусор" в признаке "Опыт работы", превышающий реальный возможный стаж трудовой деятельности ("Возраст"-16, оранжевая линия), что говорит о завышении опыта работы соискателями.
  • Отдельные показатели опыта работы (красная линия) превышают даже возраст соискателя.

Дополнительно А*. Распределение показателя пассионарности и мобильности соискателей¶

  • Расмотрим как влияет возраст соискателей на готовность к переезду и коммандировкам

дополнительное задание

In [26]:
#уберем выброс для наглядности
mask_age = hh_data['Возраст'] < 100 

# Сформируем признак-категорию готовности
def mobile_alacrity_predict(row:pd.Series)->str:
    if row['Готовность к переезду'] and row['Готовность к командировкам']:
        return 'К переезду и командировкам'
    elif not row['Готовность к переезду'] and row['Готовность к командировкам']:
        return 'Только к переезду'
    elif row['Готовность к переезду'] and not row['Готовность к командировкам']:
        return 'Только к командировкам'
    else:
        return 'Отсутствует'

hh_data['Готовность'] = hh_data.apply(mobile_alacrity_predict, axis=1)

fig = px.histogram( data_frame= hh_data[mask_age], 
    height=740, # width=700, 
    title='Распределение возраста соскателей по группам<br>готовых к смене места жительства и командировкам',
    x='Возраст',
    color='Готовность',
    marginal='box',
    barmode='overlay',
).update_layout(bargap=0.05)
fig.show()

del mask_age
#(!) Обязательно удаляем доп. признаки для анализа, иначе кол-во дубликатов и т.п. 
# будут не соответствовать тестовым значениям. Или используем df.assign(...) для графиков
hh_data.drop(columns='Готовность', inplace=True)

Вывод

  • В целом распределения перекрываются. Распределение сооскателей, готовых к переезду, немного смещенно к более старшей возрастной группе.
  • Пик (мода) соискателей, готовых к переезду приходиться на 30 лет. Моды групп не готовых к перезду приходится на 24 года.
  • Если до 24 лет (студенты) приобладает возрастная группа "домоседов", то в последующем количество готовых к переезду и готовых и к переезду и к командировками превышает остальные группы.
  • Группа готовых только на командировки значительно меньше остальных на всем распределении возраста соискателей.
  • К 50 годам количество готовых на переезды с командировками и количество не готовых к этому примерно уравнивается. А вот тех кто готов к переезду для смены работы, но не желает жить командировками остается стабильно больше до 60 лет.

Дополнительно Б*. Распределение Топ10 ожидаемой медианной зарплаты из Топ25 самых популярных категориях искомых должностей¶

  • Рассмотрим как распределяется Топ10 самых ожидаемых зарпат из Топ25 самых популярных категориях искомых должностей

дополнительное задание

In [27]:
# Ограничемся реалистичной минимальной зарплатой в 12т (близко к МРОТ)
# и более реалистичными зарплатами в максимуме. Топ менеджеры вряд ли ищут работу через hh.ru
mask_salary_bw = (hh_data['ЗП (руб)'] > 12_000) & (hh_data['ЗП (руб)'] < 350_000) 

# Отберем в категории топ 25 искомых должностей по количеству соскателей
top25_jobs = hh_data.groupby('Ищет работу на должность:')['ЗП (руб)'] \
                .agg(['count', 'median']).nlargest(25, columns='count')

# Выберем из них топ 10 самых больших ожидаемых медианных зарплат
top10_salary = top25_jobs.nlargest(10, columns='median').index

# display(top25_jobs.nlargest(10, columns='median'))
# Аналитики лидирую по количеству, руководители проектов по з/п. И это топ 10. Кризис, однако.

#отбираем и рисуем
top10_salary_mask = hh_data['Ищет работу на должность:'].isin(top10_salary)
fig = px.box(
    height=720, # width=700, 
    data_frame=hh_data[mask_salary_bw & top10_salary_mask],
    title='Распределение желаемой заработной платы в топ 10 ожидаемых<br>медианных зарплат из топ 25 искомых должностей',
    x='ЗП (руб)',
    y='Ищет работу на должность:',
    color='Ищет работу на должность:'
).update_layout(showlegend=False)
fig.show()

del mask_salary_bw, top25_jobs, top10_salary, top10_salary_mask

Вывод

  • В целом распределения ожидаемой з/п более-менее реалистичные. В сторону более максимальной з/п ожидаемо выделяются группы "Руководителей проекта/проектов". "Менеджеры проектов" это скорее всего соискатели из IT-сферы и т.п., а "Руководители" это более широкий спектр направлений.
  • По IT-сфере несколько заниженна ожидаемая з/п у групп "Инженер-программист" и "Программист". Похоже на наименование должностей системных администраторов в бюджетной сфере и тогда соответствует действительности.
  • Можно отметить, что в группе "Програмист-разработчик" несколько завышенная ожидаемая з/п относительно групп "Frontend-разработчик" и "Аналитик".

Очистка данных¶

In [28]:
#Далее работаем с копией данных для очистки
hh_cleaned = hh_data.copy()

Удаление дубликатов.¶

In [29]:
# Ищем дубликаты, выводим их количество
hh_duplicates = hh_cleaned[hh_cleaned.duplicated()]
print('Кол-во полных дубликатов:', hh_duplicates.shape[0])

# и удаляем. смотрим что осталось. (44744-161)
hh_cleaned.drop_duplicates(inplace=True)
print('Результирующее число записей:', hh_cleaned.shape[0])
Кол-во полных дубликатов: 161
Результирующее число записей: 44583

Работа с пропусками¶

In [30]:
print('Признаки с пропусками:')
print('NaN`s   Column')
for colm in hh_cleaned.columns:
    cnt_na = hh_cleaned[colm].isna().sum()
    if cnt_na:  print(f'{cnt_na:5}  ', colm)
Признаки с пропусками:
NaN`s   Column
    1   Последнее/нынешнее место работы
    2   Последняя/нынешняя должность
  168   Опыт работы (месяц)
In [31]:
# удалим столбцы с малым кол-вом пропусков
subset = ['Последнее/нынешнее место работы', 'Последняя/нынешняя должность']
hh_cleaned.dropna(subset=subset, inplace=True)  

# заполняем медианым значением признак со значительными пропусками
hh_cleaned.fillna({'Опыт работы (месяц)': hh_cleaned['Опыт работы (месяц)'].median()}, inplace=True)
# Проверяем
print('Кол-во пропусков "Опыт работы":', hh_cleaned['Опыт работы (месяц)'].isna().sum())
# И в целом
print('Результирующее число записей:', hh_cleaned.shape[0])
print('Общее кол-во пропусков:',hh_cleaned.isna().sum().sum())
print('Среднее "Опыт работы (месяц)":', hh_cleaned['Опыт работы (месяц)'].mean())
Кол-во пропусков "Опыт работы": 0
Результирующее число записей: 44581
Общее кол-во пропусков: 0
Среднее "Опыт работы (месяц)": 114.35777573405711

Ликвидация выбросов. Признак "ЗП (руб)"¶

In [32]:
# Отфильтруем, оценим и удалим
mask_not_bw_1k_1m = (hh_cleaned['ЗП (руб)'] < 1E3) | (hh_cleaned['ЗП (руб)'] > 1E6)

print('Результирующее число записей до очистки:', hh_cleaned.shape[0])
print('Кол-во выбросов в "ЗП" менее 1K и более 1M:', hh_cleaned[mask_not_bw_1k_1m].shape[0])

hh_cleaned.drop(index=hh_cleaned[mask_not_bw_1k_1m].index, inplace=True)
del mask_not_bw_1k_1m
print('Результирующее число записей после очистки:', hh_cleaned.shape[0])
Результирующее число записей до очистки: 44581
Кол-во выбросов в "ЗП" менее 1K и более 1M: 89
Результирующее число записей после очистки: 44492

Ликвидация выбросов. Признак "Опыт работы (месяц)"¶

In [33]:
mask_work_over_age = hh_cleaned['Опыт работы (месяц)'] > hh_cleaned['Возраст']*12

print('Результирующее число записей до очистки:', hh_cleaned.shape[0])
print('Кол-во выбросов с превышением опыта работы:', hh_cleaned[mask_work_over_age].shape[0])

hh_cleaned.drop(index=hh_cleaned[mask_work_over_age].index, inplace=True)
del mask_work_over_age
print('Результирующее число записей после очистки:', hh_cleaned.shape[0])
Результирующее число записей до очистки: 44492
Кол-во выбросов с превышением опыта работы: 7
Результирующее число записей после очистки: 44485

Ликвидация выбросов. Признак "Возраст". Метод Z-отклонений в логорифмическом маштабе¶

In [34]:
# Очистка от выбросов в признаке "Возраст" методом трех сигм
# Сперва посмотрим распределение на графике в логарифмическом маштабе

hh_cleaned['Возраст (лог)'] = np.log(hh_cleaned['Возраст'])
# Заготовочка с курса и в право 4 сигмы
mu = hh_cleaned['Возраст (лог)'].mean()
sigma = hh_cleaned['Возраст (лог)'].std()
left_shift  = 3
right_shift = 4
lower_bound = mu - left_shift  * sigma 
upper_bound = mu + right_shift * sigma

fig = px.histogram( data_frame=hh_cleaned, 
    height=540, # width=700, 
    title='Распределение возраста соискателей (логарифмический маштаб)',
    x='Возраст (лог)',
    marginal='box',
    range_y=[0, 3000],
).update_layout(bargap=0.05)

# (!) Для отображения результата на графике наведите курсор в нижную часть линии

fig.add_trace( go.Scatter(x=[mu,mu], y=[0,3000], mode='lines', name='Mean',
    line = {'color':'red','width':2}))
fig.add_trace( go.Scatter(x=[lower_bound, lower_bound], y=[0,3000], mode='lines', name='Lower bound',
    line = {'color':'green','width':2}))
fig.add_trace( go.Scatter(x=[upper_bound, upper_bound], y=[0,3000], mode='lines', name='Upper bound',
    line = {'color':'orange','width':2}))

fig.show()
In [35]:
# Выводим список значений выбросов
mask_log_age_emissions = (hh_cleaned['Возраст (лог)'] < lower_bound) | (hh_cleaned['Возраст (лог)'] > upper_bound)
print('Кол-во выбросов "Возраст (лог)":', mask_log_age_emissions.sum())
display(hh_cleaned[mask_log_age_emissions][['Возраст (лог)','Возраст']])

print('Нижняя граница (срд-3*сигмы):', round(np.exp(lower_bound)))
print('Верхняя граница (срд+4*сигмы):', round(np.exp(upper_bound)))

# Удостоверимся что лог. распределение лево-симмитричное, как на графике
print('Кооф. асимметрии:', round(hh_cleaned['Возраст (лог)'].skew(), 2))

#Удаляем выбросы
hh_cleaned.drop(index=hh_cleaned[mask_log_age_emissions].index, inplace=True)

del mask_log_age_emissions
hh_cleaned.drop(columns='Возраст (лог)', inplace=True)
Кол-во выбросов "Возраст (лог)": 3
Возраст (лог) Возраст
31137 2.70805 15
32950 2.70805 15
33654 4.60517 100
Нижняя граница (срд-3*сигмы): 16
Верхняя граница (срд+4*сигмы): 79
Кооф. асимметрии: 0.45

Вывод

  • Полученые границы возраста для выявления выбросов с использованием метода z-отклонения состовляют - менее 16 и более 79 лет
  • Логарифмическое распределение возраста смещено в лево и является левосторонним.

Итог работы по очистке¶

In [36]:
print('Результирующее число записей до очистки:', hh_data.shape[0])
print('Результирующее число записей после очистки:', hh_cleaned.shape[0])
print('Удалено записей:', hh_data.shape[0]  - hh_cleaned.shape[0])
Результирующее число записей до очистки: 44744
Результирующее число записей после очистки: 44482
Удалено записей: 262

Общий вывод¶

*В современном мире, при повсеместном использовании средст цифровой обработки и автоматизации множества процессов, разнообразии форм и содержание различных данных для анализа предпологает не только отличное знание инструментальных средст и математического аппарата, но умение использовать эмпирические методы, основанные на здравом смысле, жизненном опыте и

практических навыках. И визуальный анализ данных является отличным инструментом к таким методам.

Эвристика и творческий подход к обработке данных могут значительно улучшить качество результата.*

SF DST-148 Дмитрий Орлов Февраль 2023


Онлайн-школа SkillFactory Курсы по Data Science